/*
 * Copyright Aristool AG Switzerland
 *
 * Licensed under the Apache License, Version 2.0 (the "License"); 
 * you may not use this file except in compliance with the License. 
 * You may obtain a copy of the License at 
 * 
 * http://www.apache.org/licenses/LICENSE-2.0 
 * 
 * Unless required by applicable law or agreed to in writing, software 
 * distributed under the License is distributed on an "AS IS" BASIS, 
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 
 * See the License for the specific language governing permissions and 
 * limitations under the License.
 */
package org.opprua.tidy.util;

import java.io.Serializable;
import java.lang.reflect.Constructor;
import java.sql.Time;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collections;
import java.util.Date;
import java.util.Iterator;
import java.util.List;
import java.util.TimeZone;

/**
 * TODO: add Java doc
 * 
 * Prepared for first commit: 15.04.2007
 * 
 * @author Lin Li
 */
public class TimePoint implements Serializable, Comparable<Object> {

  private static final long serialVersionUID = 1L;

  // just to make sure that all formats will use the same time zone.
  private static TimeZone timeZone = TimeZone.getDefault();

  /**
   */
  public static class Formats {

    private static List<Formats> store = Collections
                .synchronizedList(new ArrayList<Formats>());

    private DateFormat d;

    private static synchronized void resetTimeZone() {
      for (Iterator<Formats> iter = store.iterator(); iter.hasNext();) {
        Formats f = iter.next();
        synchronized (f.d) {
          f.d.setTimeZone(timeZone);
        }
      }
    }

    /**
     * Constructor for formats. Any new formats constructed is automatically
     * registered with the Watch and will be used for all later parsing trials.
     * 
     * @param format
     *          the simple format string for SimpleDateFormat
     */
    public Formats(String format) {
      d = new SimpleDateFormat(format);
      d.setTimeZone(timeZone);
      store.add(this);
    }

    /**
     * The string s is assumed to be some presentation of date/time. Formats
     * will apply all the formats known to it to guess the time. If any of them
     * succeeds, the trial ends, otherwise it will go on until all formats known
     * fail. Then the guessing also fails.
     * 
     * @param s
     *          the string containing a time
     * @return the time guessed
     */
    public static synchronized long getDate(String s) {
      if (s == null || s.trim().length() == 0) {
        throw new Error("No date/time found: " + s);
      }

      Iterator<Formats> i = store.iterator();
      while (i.hasNext()) {
        DateFormat f = i.next().d;
        try {
          long l = getDate(f, s);
          return l;
        } catch (Throwable x) {}
      }
      throw new Error("Date format unrecognized: " + s);
    }

    /**
     * Parse the string containing a date in the given formats.
     * 
     * @param f
     *          the formats of the string
     * @param s
     *          the date/time representation string in formats given
     * @return the guessed time.
     */
    public static final long getDate(Formats f, String s) {
      if (f == null) {
        return getDate(s);
      } else if (s == null || s.trim().length() == 0) {
        throw new Error("No date/time found: " + s);
      }
      return getDate(f.d, s);
    }

    public long unformat(String s) {
      return getDate(this, s);
    }

    private static long getDate(DateFormat f, String s) {
      if (f == null) throw new Error("No format provided: " + s);

      try {
        synchronized (f) {
          return f.parse(s.trim()).getTime();
        }
      } catch (Throwable x) {
        throw new Error("Date format does not match: " + s, x);
      }
    }

    /**
     * Formats the time given in the formats given.
     * 
     * @param f
     *          the format to use
     * @param s
     *          the time to format
     * @return the string containing the time formated in formats.
     */
    public static String format(Formats f, long s) {
      if (f == null) {
        throw new Error("Hard to format with no formats");
      }
      synchronized (f.d) {
        return f.d.format(new java.sql.Date(s));
      }
    }
  }

  /**
   * SR date format: dd MMM yyyy
   */
  public static final Formats SWISSRE_DATE = new Formats("dd MMM yyyy");
  /**
   * EUROPEAN_TIME format: dd.MM.yyyy HH:mm:ss:SSS
   */
  /**
   * EUROPEAN_DATE format: dd.MM.yyyy
   */
  public static final Formats EUROPEAN_DATE = new Formats("dd.MM.yyyy");
  /**
   * EUROPEAN_TIME format: dd.MM.yyyy HH:mm:ss:SSS
   */
  public static final Formats EUROPEAN_TIME = new Formats(
              "dd.MM.yyyy HH:mm:ss:SSS");
  /**
   * DATABASE_TIME format: 'HH.mm.ss.SSS'
   */
  public static final Formats DATABASE_TIME = new Formats("''HH.mm.ss''");
  /**
   * AMERICAN_TIME format: MM/dd/yyyy 'at' HH:mm:ss:SSS a
   */
  public static final Formats AMERICAN_TIME = new Formats(
              "MM/dd/yyyy 'at' HH:mm:ss:SSS a");

  /**
   * DATABASE_TIMESTAMP formats: 'yyyy-MM-dd-HH.mm.ss.SSS'
   */
  public static final Formats DATABASE_TIMESTAMP = new Formats(
              "''yyyy-MM-dd-HH.mm.ss.SSS''");

  /**
   * NORMAL_TIMESTAMP formats: yyyy-MM-dd-HH.mm.ss.SSS
   */
  public static final Formats NORMAL_TIMESTAMP = new Formats(
              "yyyy-MM-dd-HH.mm.ss.SSS");

  static {
    new Formats("dd.MM.yy HH:mm:ss:SSS");
    new Formats("MM/dd/yy 'at' hh:mm:ss:SSS a");

    new Formats("MM/dd/yyyy hh:mm:ss:SSS a");
    new Formats("MM/dd yyyy 'at' hh:mm:ss:SSS a");

    new Formats("MM/dd/yy hh:mm:ss:SSS a");
    new Formats("MM/dd yy 'at' hh:mm:ss:SSS a");
  }

  /**
   * DATABASE_DATE format: 'yyyy-MM-dd'
   */
  public static final Formats DATABASE_DATE = new Formats("''yyyy-MM-dd''");
  /**
   * AMERICAN_DATE format: MM/dd/yyyy
   */
  public static final Formats AMERICAN_DATE = new Formats("MM/dd/yyyy");

  /**
   * NORMAL_DATE formats: yyyy-MM-dd
   */
  public static final Formats NORMAL_DATE = new Formats("yyyy-MM-dd");

  /**
   * TIME_OF_DAY formats: HH:mm:ss
   */
  public static final Formats TIME_OF_DAY = new Formats("HH:mm:ss");

  /**
   * DB_TIME_OF_DAY formats: HH:mm:ss
   */
  public static final Formats DB_TIME_OF_DAY = new Formats("''HH:mm:ss''");

  static {
    new Formats("dd.MM.yy");
    new Formats("yy-MM-dd");
    new Formats("MM/dd/yy");

    new Formats("yyyyMMdd");
    new Formats("MM/dd yyyy");
    new Formats("dd.MM yyyy");

    new Formats("MM/dd yy");
    new Formats("dd.MM yy");
  }

  /**
   * COMPACT_DATE formats: yyyyMMdd
   */
  public static final Formats COMPACT_DATE = new Formats("yyyyMMdd");

  /**
   * COMPACT_TIME formats: yyyyMMddHHmmss
   */
  public static final Formats COMPACT_TIME = new Formats("yyyyMMddHHmmss");

  /**
   * Time marker for very early.
   */
  public static TimePoint TIME_BEGIN = new TimePoint(-1);
  /**
   * Time marker for very late.
   */
  public static TimePoint TIME_END = new TimePoint(-2);
  /**
   * Place holder object for a watch that does not have a time.
   */
  public static TimePoint NOT_A_TIME = new TimePoint(-3);

  public static final long TIME_BEGIN_VALUE = NORMAL_TIMESTAMP
              .unformat("0001-01-01-00.00.00.000");
  // 0001-01-01-00.00.00.000000
  public static final long TIME_END_VALUE = NORMAL_TIMESTAMP
              .unformat("9999-12-31-23.59.59.999");
  // 9999-12-31-23.59.59.999999

  public static final long TIME_EARLY_VALUE = NORMAL_TIMESTAMP
              .unformat("1001-01-02-23.59.59.999999");
  // 1001-01-02-23.59.59.999999
  public static final long TIME_LATE_VALUE = NORMAL_TIMESTAMP
              .unformat("8999-12-29-23.59.59.999999");
  // 8999-12-29-23.59.59.999999

  public static final int TIME_BAD_INTERVAL = (int) TIME_OF_DAY
              .unformat("13:00:00");

  /**
   * Provides the offset for the time given according to the current time zone
   * of Watch class.
   * 
   * @return the offset from GMT at the time given in milliseconds
   */
  public static synchronized int getSourceOffset(long time) {
    return getSourceOffset(time, timeZone);
  }

  /**
   * Provides the offset for the time given according to the time zone given.
   * 
   * @return the offset from GMT at the time given in milliseconds
   */
  public static synchronized int getSourceOffset(long time, TimeZone tz) {
    // very very stupid code. Java 1.4 would have been better....
    Calendar c = Calendar.getInstance();
    c.setTimeZone(tz);
    c.setTime(new Date(time));
    return tz.getOffset(c.get(Calendar.ERA), c.get(Calendar.YEAR), c
                .get(Calendar.MONTH), c.get(Calendar.DAY_OF_MONTH), c
                .get(Calendar.DAY_OF_WEEK), c.get(Calendar.MILLISECOND));
  }

  /**
   * Set the time zone for the Watch class
   */
  public static synchronized void setTimeZone(TimeZone t) {
    timeZone = t;
    Formats.resetTimeZone();
  }

  /**
   * Set the time zone for the Watch class to be the default time zone of the
   * virtual machine
   */
  public static synchronized void resetTimeZone() {
    timeZone = TimeZone.getDefault();
    Formats.resetTimeZone();
  }

  private long time;
  private int offset;

  protected TimePoint(long t) {
    this(t, timeZone);
  }

  private TimePoint(long t, TimeZone tz) {
    time = -1L;
    if (t < TIME_EARLY_VALUE) {
      offset = -1;
    } else if (t > TIME_LATE_VALUE) {
      offset = -2;
    } else if (t < TIME_BAD_INTERVAL && t > -TIME_BAD_INTERVAL) {
      offset = -1;
    } else {
      time = t;
      offset = getSourceOffset(t, tz);
    }
  }

  private TimePoint(java.sql.Time t) {
    time = t.getTime();
    if (time == -1L) {
      time = 0L;
    }
    offset = getSourceOffset(time);
  }

  private TimePoint(int t) {
    time = -1L;
    offset = t;
  }

  private TimePoint fakeSource() {
    if (time == -1L) {
      return this;
    }
    return new TimePoint(time + offset - getSourceOffset(time));
  }

  /**
   * Get a watch with current (system) time and offset from Watch class.
   * 
   * @return the new watch
   */
  public static TimePoint from() {
    return new TimePoint(System.currentTimeMillis());
  }

  /**
   * Get a watch with the time given and offset from Watch class.
   * 
   * @return the new watch
   */
  public static TimePoint from(long time) {
    return new TimePoint(time);
  }

  /**
   * Get a watch with the time given and offset from the given timezone.
   * 
   * @return the new watch
   */
  public static TimePoint from(long time, TimeZone tz) {
    return new TimePoint(time, tz);
  }

  public static TimePoint lookInto(Object o) {
    return from(o);
  }

  public String tell(Formats f) {
    return local(f);
  }

  public Date tellDate() {
    return getDate(Date.class);
  }

  /**
   * Get a watch with time described in o, if o is not a recognized object,
   * raise exception (Mess). A special case is the empty string. Looking into a ""
   * returns a <tt>NOT_A_TIME</tt>. The offset is calculated from time zone
   * of the watch class.
   * 
   * @return the new watch
   */
  public static TimePoint from(Object o) {
    if (o == null) {
      throw new Error("Can not get a time out of nothing");
    }
    if (o instanceof TimePoint) {
      return (TimePoint) o;
    } else if (o instanceof String) {
      if (((String) o).trim().length() == 0) {
        return NOT_A_TIME;
      }
      return from(null, (String) o);
    } else if (o instanceof Long) {
      return from(((Long) o).longValue());
    } else if (o instanceof java.util.Date) {
      if (o instanceof java.sql.Timestamp) {
        java.sql.Timestamp t = (java.sql.Timestamp) o;
        return from(t.getTime() + t.getNanos() / 1000000);
      } else if (o instanceof java.sql.Time) {
        return new TimePoint((java.sql.Time) o);
      }
      return from(((java.util.Date) o).getTime());
    } else if (o instanceof Calendar) {
      return from(((Calendar) o).getTime().getTime());
    }
    throw new Error("Can not get a time out of a " + o.getClass());
  }

  /**
   * Get watch with the time described in o. If o is not recognized, the
   * alt(ernate) watch is returned. The offset is calculated from time zone of
   * the watch class.
   * 
   * @return the new watch
   */
  public static TimePoint from(Object o, TimePoint alt) {
    try {
      return from(o);
    } catch (Throwable e) {
      return alt;
    }
  }

  /**
   * Get a watch with the time described in t with format f. The offset is
   * calculated from time zone of the watch class.
   * 
   * @return the new watch
   */
  public static TimePoint from(Formats f, String t) {
    return from(Formats.getDate(f, t));
  }

  /**
   * Retrieve the universal time. The watch should be at least a time (can not
   * be NOT_A_TIME) for this to succeed.
   * 
   * @return the universal time, milliseconds from 1970-01-01-00.00.00.000 UTC
   */
  public long getTime() {
    if (time == -1L) {
      if (offset == -1) {
        return TIME_BEGIN_VALUE;
      } else if (offset == -2) {
        return TIME_END_VALUE;
      } else {
        throw new Error("The watch is not a time, no time available");
      }
    }
    return time;
  }

  /**
   * Retrieve the universal time. The watch must be a regular time for this to
   * succeed.
   * 
   * @return the universal time, milliseconds from 1970-01-01-00.00.00.000 UTC
   */
  public long getRegularTime() {
    if (time == -1L) {
      throw new Error("The watch is not a regular time");
    }
    return time;
  }

  /**
   * Retrieve the time offset of this watch at its origin (remote). The watch
   * has to be a regular time for this to succeed.
   * 
   * @return the offset, milliseconds difference to UTC time.
   */
  public int getOffset() {
    if (time == -1L) {
      throw new Error("The watch is not a regular time, no offset available");
    }
    return offset;
  }

  /**
   * Test whether this watch holds a regular time. A regular time holds a
   * universal time and an offset from the originating time zone. There are
   * three watches that are not regular: <tt>TIME_BEGIN</tt>,
   * <tt>TIME_END</tt>, and <tt>NOT_A_TIME</tt>.
   * 
   * @return true for this is a regular time
   */
  public boolean isRegularTime() {
    return time != -1L;
  }

  /**
   * Test whether this watch holds any sort of time data. Every watch is a time
   * beside <tt>NOT_A_TIME</tt>.
   * 
   * @return true for this is a time
   */
  public boolean isTime() {
    return time != -1L || offset != -3;
  }

  /**
   */
  public <T extends java.util.Date> T getDate(Class<T> t) {
    try {
      Constructor<T> c = t.getConstructor(new Class[] {
        long.class
      });
      return c.newInstance(new Object[] {
        getTime()
      });
    } catch (Throwable x) {
      throw new Error("Failed to create date: " + t.getName(), x);
    }
  }

  /**
   * Get a new watch with the time at the end of the same local day on this
   * watch. The offset of the new watch is from the local watch class. That is,
   * the new watch is from this location.
   * 
   * @return the new watch.
   */
  public TimePoint localEndOfDay() {
    return from(local(NORMAL_DATE) + "-23.59.59.999");
  }

  /**
   * Get a new watch with the time at the begin of the same local day on this
   * watch. The offset of the new watch is from the local watch class. That is,
   * the new watch is from this location.
   * 
   * @return the new watch.
   */
  public TimePoint localStartOfDay() {
    return from(local(NORMAL_DATE));
  }

  /**
   * Get a new watch with the time at the end of the same day at the source of
   * this watch. The offset of the new watch is the same as in this watch. That
   * is, the new watch behaves as if it is also from the source.
   * 
   * @return the new watch.
   */
  public TimePoint sourceEndOfDay() {
    TimePoint w = from(source(NORMAL_DATE) + "-23.59.59.999");
    w.offset = offset;
    return w;
  }

  /**
   * Get a new watch with the time at the begin of the same day at the source of
   * this watch. The offset of the new watch is the same as in this watch. That
   * is, the new watch behaves as if it is also from the source.
   * 
   * @return the new watch.
   */
  public TimePoint sourceStartOfDay() {
    TimePoint w = from(source(NORMAL_DATE));
    w.offset = offset;
    return w;
  }

  /**
   * Get the time of the day locally in format HH:mm:ss.
   * 
   * @return the formated time of the day
   */
  public String localDayTime() {
    return local(TIME_OF_DAY);
  }

  /**
   * Get the time of the day at the source in format HH:mm:ss.
   * 
   * @return the formated time of the day
   */
  public String sourceDayTime() {
    return source(TIME_OF_DAY);
  }

  /**
   * Get the time in a String as specified by format f at the local time zone.
   * 
   * @return the formated time of the day
   */
  public String local(Formats f) {
    return Formats.format(f, getTime());
  }

  /**
   * Get the time in a String as specified by format f at the source time zone.
   * 
   * @return the formated time of the day
   */
  public String source(Formats f) {
    return Formats.format(f, fakeSource().getTime());
  }

  /**
   * Support for java.lang.Comparable.compareTo(Object o). The watches can be
   * compared regardless of their origins. The object to compare to can be
   * anything that holds a time (<tt>from(Object o)</tt> will be applied
   * first to the object).
   * 
   * @return the comparison result
   */
  public int compareTo(Object o) {
    TimePoint w = from(o, NOT_A_TIME);
    if (!this.isTime() || !w.isTime()) {
      throw new Error("Can not compare NOT_A_TIME");
    }
    long t1 = getTime();
    long t2 = w.getTime();

    if (t1 == t2) {
      return 0;
    }
    return t1 > t2 ? 1 : -1;
  }

  /**
   * The default conversion to String: local normal date
   */
  public String toString() {
    return local(NORMAL_DATE);
  }

  public static <T extends java.util.Date> TimePoint fake(Class<T> c) {
    long l = Math.round(Math.random() * 90000000000000L) - 30000000000000L;
    TimePoint t = new TimePoint(l);

    if (c.equals(Date.class) || c.equals(java.sql.Date.class)) {
      return lookInto(t.tell(NORMAL_TIMESTAMP).substring(0, 10));
    } else if (c.equals(Time.class)) {
      return lookInto(t.tell(NORMAL_TIMESTAMP).substring(11));
    }
    return t;
  }
}